Skip to content

Replace httpx and httpx-sse with httpx2#2972

Open
Kludex wants to merge 7 commits into
mainfrom
httpx2-migration
Open

Replace httpx and httpx-sse with httpx2#2972
Kludex wants to merge 7 commits into
mainfrom
httpx2-migration

Conversation

@Kludex

@Kludex Kludex commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Swaps the httpx + httpx-sse dependencies for httpx2 >=2.5.0. httpx2 is the next-generation httpx fork with server-sent events support built in, so the separate httpx-sse dependency is no longer needed.

Changes

  • Replace httpx>=0.27.1,<1.0.0 + httpx-sse>=0.4 with httpx2>=2.5.0 in the SDK and the example projects (lockfile regenerated).
  • Rewrite the SSE transports against httpx2's API: aconnect_sse(...)client.stream(...) / client.sse(...) wrapped in EventSource, and iterate the EventSource directly instead of .aiter_sse().
  • Document the swap as a v2 breaking change in docs/migration.md; update docs/installation.md, README.v2.md, and the example sources.

Notes

  • httpx2 is API-compatible with httpx, so most of the diff is the httpxhttpx2 import rename. Users passing their own http_client only need to change the import.
  • Tests use httpx2's AsyncClient.sse() convenience method for the raw-client SSE call sites.

Verification

ruff, pyright, and the full test suite all pass at 100% coverage (strict-no-cover clean).

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 65 files

Tip: instead of fixing issues one by one fix them all with cubic
Partial review: This PR has more than 50 files, so cubic reviewed the highest-priority files first. During the trial, paid plans get a higher file limit.
You can try an ultrareview to bypass the file limit, comment @cubic-dev-ai ultrareview. Learn more.

Re-trigger cubic

Comment thread src/mcp/client/streamable_http.py Outdated
Comment thread src/mcp/client/sse.py Outdated
Comment thread examples/clients/simple-chatbot/mcp_simple_chatbot/main.py Outdated
Comment thread examples/clients/simple-chatbot/mcp_simple_chatbot/main.py Outdated
Comment thread docs/migration.md Outdated
Comment thread pyproject.toml Outdated
Comment on lines 30 to 36
# stderr (agronholm/anyio#816, fixed in 4.10).
"anyio>=4.10; python_version >= '3.14'",
"anyio>=4.9; python_version < '3.14'",
"httpx>=0.27.1,<1.0.0",
"httpx-sse>=0.4",
"httpx2>=2.5.0",
"pydantic>=2.12.0",
"starlette>=0.48.0; python_version >= '3.14'",
"starlette>=0.27; python_version < '3.14'",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Making the just-published httpx2/httpcore2 packages (uploaded to PyPI ~45 minutes before this PR was opened, per the lockfile timestamps) the SDK's sole HTTP dependency deserves explicit provenance/maturity vetting before merge, and the swap also silently changes TLS verification: httpcore2/httpx2 drop certifi for truststore, so certificate validation moves from the certifi CA bundle to the OS trust store for every SDK user. At minimum, document the certifi→truststore TLS behaviour change in docs/migration.md (which currently only covers the import rename) and confirm the fork's publisher before pinning the SDK to an hours-old 2.5.0 release.

Extended reasoning...

What the change does. The PR replaces httpx>=0.27.1,<1.0.0 + httpx-sse>=0.4 with httpx2>=2.5.0 as the SDK's only HTTP stack (pyproject.toml lines 30–36), and the regenerated uv.lock shows the transport swap underneath it: httpcore (which depends on certifi + h11) is replaced by httpcore2 (which depends on h11 + truststore), and httpx2 itself also pulls in truststore instead of certifi.

Maturity/provenance concern. The lockfile records the upload times of the new packages: httpcore2-2.5.0 at 2026-06-25T14:16:53–56Z and httpx2-2.5.0 at 2026-06-25T14:16:55–57Z, while this PR was opened at 2026-06-25T15:03:08Z. In other words, the SDK would pin its entire HTTP/SSE/OAuth transport to packages that were published to PyPI less than an hour before the PR — zero deployment track record, no ecosystem usage, and the PR description asserts "next-generation httpx fork" without substantiating who publishes it. The wheel metadata does list Tom Christie as author and Pydantic Services Inc. as maintainer with a github.com/pydantic/httpx2 homepage, which softens the worst-case typosquat scenario, but that metadata is self-declared and should be verified by maintainers (confirm the PyPI publisher, the GitHub org, and that this is the intended successor to httpx) before the SDK adopts it as its sole HTTP dependency.

The undocumented TLS behaviour change. This is the independently actionable part regardless of how the provenance question resolves. With httpx <1.0, default TLS verification used the certifi CA bundle. httpx2 2.5.0's create_ssl_context() instead defaults to truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) — the operating system trust store — unless SSL_CERT_FILE/SSL_CERT_DIR are set. Every SDK user's HTTP, SSE, and OAuth requests therefore start validating server certificates against a different trust root the moment they upgrade.

Concrete walk-through of how this manifests. Take a user running an MCP client in a corporate environment behind a TLS-intercepting proxy whose root CA is installed in the OS trust store but (deliberately) not in certifi. On v1, streamable_http_client(...)create_mcp_http_client()httpx.AsyncClient() → certifi bundle → the proxy's certificate is rejected with SSLCertVerificationError (the behaviour they have built tooling/expectations around, e.g. mounting a custom bundle via SSL_CERT_FILE). After this PR, the same call path goes httpx2.AsyncClient() → truststore → OS store → the connection now succeeds. The inverse failure also exists: a minimal container image (e.g. python:slim derivatives without ca-certificates installed, or distroless variants) that previously worked because certifi ships its own bundle will now fail every TLS handshake because the OS store is empty. Neither direction is hinted at anywhere — docs/migration.md documents only the httpxhttpx2 import rename and the SSE helper change.

Why nothing else in the PR mitigates this. The migration guide explicitly claims "httpx2 is API-compatible with httpx, so usually only the import name changes", which actively tells users there is no behavioural change to look for. The test suite runs entirely against in-process ASGI transports and MockTransport, so no test exercises real TLS and the change is invisible in CI.

How to fix. (1) Add a section to docs/migration.md (and ideally docs/installation.md) stating that TLS verification now uses the system trust store via truststore instead of the certifi bundle, and how to restore the old behaviour (set SSL_CERT_FILE to certifi's bundle, or pass an explicit verify= SSL context to a user-supplied httpx2.AsyncClient). (2) Before merging, have a maintainer verify the httpx2/httpcore2 PyPI publisher and decide whether pinning to a release that is hours old (>=2.5.0 with no upper bound) is acceptable for the SDK, or whether to wait for the fork to accumulate a track record / add a stricter version constraint.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ The documentation half of this is now addressed: commit c5d54b2 added a section to docs/migration.md explaining that TLS verification moves from the bundled certifi CA list to the OS trust store via truststore, including the minimal-container failure mode and the verify=ssl_context escape hatch.

Still outstanding is the provenance/maturity half: pyproject.toml continues to pin httpx2>=2.5.0 (no upper bound) — a release that was published to PyPI less than an hour before this PR was opened — and uv.lock resolves the transport stack to the equally new httpcore2 2.5.0. Before merge, a maintainer should (1) confirm the httpx2/httpcore2 PyPI publisher and the github.com/pydantic/httpx2 org match the intended httpx successor, and (2) decide whether an unbounded >=2.5.0 constraint on an hours-old fork is acceptable for the SDK's sole HTTP dependency, or whether a tighter cap (e.g. <3) / waiting for a track record is warranted. No further code change is needed for the TLS documentation point.

Comment thread src/mcp/client/sse.py Outdated
Comment thread examples/clients/sse-polling-client/mcp_sse_polling_client/main.py Outdated
@maxisbey

Copy link
Copy Markdown
Contributor

.github/actions/conformance/client.py was missed in the rename sweep — it still does import httpx (line 35) and builds httpx.AsyncClient (lines 130, 346). The client-conformance workflow runs it via uv run --frozen against this repo's uv.lock, which no longer contains httpx after this swap, so the job will fail at import with ModuleNotFoundError: No module named 'httpx'. Update the script to import httpx2 / httpx2.AsyncClient like the rest of the codebase.

Kludex and others added 6 commits June 29, 2026 15:29
httpx2 (2.5.0) is the next-generation httpx fork with server-sent
events support built in, so the separate httpx-sse dependency is no
longer needed.

- Swap the httpx/httpx-sse dependencies for httpx2>=2.5.0 in the SDK
  and the example projects.
- Rewrite the SSE transports against httpx2's API: aconnect_sse(...) ->
  client.stream(...)/client.sse(...) wrapped in EventSource, and
  iterate the EventSource directly instead of .aiter_sse().
- Document the swap as a v2 breaking change in docs/migration.md and
  update docs/installation.md, README.v2.md, and the example sources.

Verified: ruff, pyright, and the full test suite pass at 100% coverage.
The rebase onto main picked up files added since the swap (stories
examples, identity-assertion client/docs, client probe, docs_src
tutorials) that still imported httpx. Apply the same httpx -> httpx2
rename to them, update the migration guide's mcp-types and
identity-assertion sections to name httpx2, and document the
certifi -> truststore TLS verification change.
httpx-sse's aconnect_sse() always sent Accept: text/event-stream and
Cache-Control: no-store; the swap to bare client.stream() dropped both.
Open the legacy SSE GET and the streamable HTTP GET/resumption/
reconnection streams with AsyncClient.sse(), which injects those
headers (explicit caller headers still take precedence), and update
the mocked sse_client test to drive the new call.

Example fixes from the same review pass:
- simple-chatbot: catch httpx2.HTTPError instead of RequestError so
  raise_for_status() failures take the handled path (the
  HTTPStatusError isinstance branch was unreachable), and drop the
  Raises section the method never honoured.
- sse-polling-client: suppress the httpcore2 logger; httpcore is no
  longer in the dependency tree, so the old suppression was a no-op.
httpx2's ServerSentEvent declares id as str defaulting to empty, where
httpx-sse allowed str | None. The handler under test checks truthiness,
so the default is behaviourally identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants